代码篇:SSR 工作原理
理解 SSR 的底层原理对于后续使用 Nuxt 等框架至关重要。本文参照 Vue.js 官方文档的服务端渲染指南,从零搭建一个最小化的 SSR 应用,深入理解服务端渲染的完整流程。
SSR 的核心流程
SSR 的核心思路分为两个阶段:
- 服务端渲染:在 Node.js 环境中将 Vue 组件渲染为 HTML 字符串,直接响应给浏览器
- 客户端激活(Hydration):浏览器加载 JS 后,Vue 接管已有的 HTML DOM,绑定事件和响应式
┌─────────────────────────────────────────────────────────┐
│ 服务端 │
│ createSSRApp() → renderToString() → HTML 字符串 │
│ ↓ │
│ HTTP Server → 响应 HTML + 引用 client.js │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ 客户端 │
│ 浏览器接收 HTML → 立即呈现页面 │
│ ↓ │
│ 加载 Vue 运行时 + client.js → Hydration → 页面可交互 │
└─────────────────────────────────────────────────────────┘
text
第一步:创建 Vue SSR 应用
初始化项目并安装 Vue:
mkdir ssr-demo && cd ssr-demo
npm init -y
bash
在 package.json 中启用 ES Module:
{
"type": "module"
}
json
安装 Vue:
npm install vue
bash
第二步:服务端渲染 HTML
创建 example.js,使用 Vue 的 createSSRApp 和 renderToString 生成 HTML:
// example.js
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
// 创建 SSR 应用实例
const app = createSSRApp({
data: () => ({ count: 0 }),
template: `<button @click="count++">{{ count }}</button>`,
})
// 将 Vue 应用渲染为 HTML 字符串
const html = await renderToString(app)
console.log(html) // 输出: <button>0</button>
javascript
执行验证:
node example.js
# 输出: <button>0</button>
bash
第三步:搭建 HTTP 服务器
将渲染结果通过 HTTP 服务响应给浏览器:
// example.js(完整版)
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { createServer } from 'node:http'
const server = createServer(async (req, res) => {
if (req.url === '/') {
// 每次请求都创建新的应用实例
const app = createSSRApp({
data: () => ({ count: 0 }),
template: `<button @click="count++">{{ count }}</button>`,
})
const appContent = await renderToString(app)
res.end(`
<!DOCTYPE html>
<html>
<head>
<title>SSR Demo</title>
</head>
<body>
<div id="app">${appContent}</div>
</body>
</html>
`)
}
})
server.listen(3000, () => {
console.log('Server is running at http://localhost:3000')
})
javascript
此时访问 http://localhost:3000,可以看到页面上的按钮,但点击没有反应 -- 因为缺少客户端 JavaScript 逻辑。
第四步:客户端激活(Hydration)
SSR 只是输出了静态 HTML 字符串,要让页面具备交互能力,还需要在客户端加载 Vue 运行时并"激活"(Hydration)已有的 DOM。
4.1 创建客户端入口文件
// client.js
import { createSSRApp } from 'vue'
const app = createSSRApp({
data: () => ({ count: 0 }),
template: `<button @click="count++">{{ count }}</button>`,
})
app.mount('#app')
javascript
4.2 在 HTML 中引入 Vue 运行时和客户端脚本
修改服务端响应,添加 <script> 标签:
res.end(`
<!DOCTYPE html>
<html>
<head>
<title>SSR Demo</title>
<!-- 使用 Import Map 引入 Vue ESM Browser 版本 -->
<script type="importmap">
{
"imports": {
"vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js"
}
}
</script>
</head>
<body>
<div id="app">${appContent}</div>
<script type="module" src="/client.js"></script>
</body>
</html>
`)
javascript
4.3 提供静态文件服务
需要让浏览器能访问到 client.js 文件:
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
// 在 server 的请求处理中添加
if (req.url === '/client.js') {
res.writeHead(200, { 'Content-Type': 'application/javascript' })
const filePath = join(__dirname, 'client.js')
try {
const data = readFileSync(filePath, 'utf-8')
res.end(data)
} catch (err) {
res.writeHead(404, { 'Content-Type': 'text/plain' })
res.end('File not found')
}
}
javascript
注意:在 ES Module 模式下(
"type": "module"),__dirname不再是全局变量。需要通过import.meta.url和fileURLToPath获取当前文件的物理路径。这是 Node.js 10.12.0 之后的变更。
完整代码
// example.js - 完整的 SSR 示例
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { createServer } from 'node:http'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
function createApp() {
return createSSRApp({
data: () => ({ count: 0 }),
template: `<button @click="count++">Clicked {{ count }} times</button>`,
})
}
const server = createServer(async (req, res) => {
// 响应首页 HTML
if (req.url === '/') {
const app = createApp()
const appContent = await renderToString(app)
res.end(`
<!DOCTYPE html>
<html>
<head>
<title>SSR Demo</title>
<script type="importmap">
{ "imports": { "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js" } }
</script>
</head>
<body>
<div id="app">${appContent}</div>
<script type="module" src="/client.js"></script>
</body>
</html>
`)
}
// 提供客户端 JS 文件
if (req.url === '/client.js') {
res.writeHead(200, { 'Content-Type': 'application/javascript' })
try {
const data = readFileSync(join(__dirname, 'client.js'), 'utf-8')
res.end(data)
} catch {
res.writeHead(404)
res.end('Not found')
}
}
})
server.listen(3000, () => {
console.log('Server is running at http://localhost:3000')
})
javascript
代码复用的优化
注意到 example.js 和 client.js 中创建 Vue 实例的代码是重复的,可以提取为公共模块:
// app.js - 共享的应用创建逻辑
import { createSSRApp } from 'vue'
export function createApp() {
return createSSRApp({
data: () => ({ count: 0 }),
template: `<button @click="count++">Clicked {{ count }} times</button>`,
})
}
javascript
然后在两个入口中引用:
// example.js(服务端)
import { createApp } from './app.js'
const app = createApp()
// client.js(客户端)
import { createApp } from './app.js'
const app = createApp()
app.mount('#app')
javascript
SSR 流程总结
| 阶段 | 执行环境 | 核心操作 |
|---|---|---|
| 服务端渲染 | Node.js | createSSRApp() + renderToString() → HTML |
| HTTP 响应 | Node.js | 返回 HTML + 引入 Vue 运行时和 client.js |
| 页面呈现 | 浏览器 | 解析 HTML,用户看到页面内容 |
| 客户端激活 | 浏览器 | 加载 Vue 运行时,执行 app.mount('#app') |
| 可交互 | 浏览器 | Vue 接管 DOM,绑定事件,页面可交互 |
这就是 SSR 的底层原理。实际项目中,Nuxt 等框架帮我们处理了 Server 创建、路由匹配、HTML 渲染、客户端 JS 生成和 Hydration 等所有复杂逻辑。理解原理后使用框架会更加得心应手。
↑